是不是最佳不知道,反正它是个实践!

预备

测试目标

每记后端是没有单元测试的,此次对其进行添加单元测试的重构。

技术选型及项目配置

主要技术如下

  • 测试平台:JUnit5
  • mock库:MockK
  • 断言库:AssertK
  • 测试覆盖率:Jacoco
  • 数据层测试:embedded-database-spring-test、flyway-spring-test、mybatis-plus-spring-boot-test

gradle配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
plugins {
id 'jacoco'
}

dependencies {
// 测试:mockk替代mockito,assertk替代assertj;embedded-database
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude module: 'mockito-core'
exclude module: 'assertj-core'
}
testImplementation("com.ninja-squad:springmockk:3.1.0")
testImplementation('com.willowtreeapps.assertk:assertk-jvm:0.25')
testImplementation('io.zonky.test:embedded-database-spring-test:2.1.1')
testImplementation('com.baomidou:mybatis-plus-boot-starter-test:3.5.1')
testImplementation('org.flywaydb.flyway-test-extensions:flyway-spring-test:7.0.0')
}

// https://docs.gradle.org/current/dsl/org.gradle.api.tasks.testing.Test.html
// 测试报告位置: build/reports/test
test {
// 使用JUnit5进行测试
useJUnitPlatform()
// 测试线程数:2
maxParallelForks(2)
// 日志配置
testLogging {
// level=LIFECYCLE的配置项
events "passed", "skipped", "failed"
exceptionFormat "full"
}
}

// 生成覆盖率测试报告: ./gradlew test jacocoTestReport
// 测试报告位置: build/jacocoReport
jacocoTestReport {
reports {
xml.enabled = false
csv.enabled = false
html.enabled = true
html.destination = file("${buildDir}/jacocoReport")
}
}

日志:src/test/resources下添加logback-test.xml文件,这里我们仅在控制台打印WARN级别的应用日志

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>

<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg %X{THREAD_ID} %n</pattern>
</encoder>
</appender>

<root level="WARN">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>

IDEA:安装JUnit插件(如果是IDEA专业版,默认已安装)。

测试思路 - MVC如何测试

被测项目是采用Spring Web MVC构建的Restful服务,那么MVC应该如何测试呢?

如果我们将每一层的职责区分得比较好,则每层负责的内容和测试思路如下

  • Controller:路由转发、参数验证
    • 重点在参数验证的测试,对Service的调用应该被Mock掉
    • 使用MockMvc
    • 使用@WebMvcTest加载单个Controller类,提升启动速度
  • Service:业务逻辑的处理
    • 常规测试,单元测试的主要工作集中在这里
    • 不使用Spring Test的任何注解,可以提升启动速度
  • Repository:数据库访问
    • 使用嵌入式数据库提供真实等价的数据库环境
    • 使用@MybatisPlusTest只加载数据库相关的Bean,提升启动速度

你写的是单元测试还是集成测试?

使用Spring Boot Test,我们写出来的往往是集成测试。即一个测试中其实包含了Controller、Service、Repository。但真的单元测试,一个测试应该只包含一个方法的一种case。

集成测试的好处是写起来方便快捷,缺点是覆盖不全面,且一旦一个调用链路的任何环节修改,相关的case都必须修改。

接下来看具体的实施情况。

Controller测试

测什么

  • 测试接口路由是否正确
  • 测试参数验证是否符合预期

一个需求

一个典型待测试的接口如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
class UserController(
private val userService: UserService,
private val subscriptionService: SubscriptionService
) {

@Authenticate
@PatchMapping("/users/{id}")
fun updateUserInfo(
@PathVariable id: Int,
@RequestBody request: UserUpdateReq,
): R<User> {
val initiator = RequestContext.getUserId()!!
return R.succeed(userService.updateUserInfo(id, initiator, request))
}

}
  • @Authenticate表示该接口需要鉴权,具体鉴权逻辑如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @Component
    class AuthInterceptor(
    @Autowired val objectMapper: ObjectMapper
    ) : HandlerInterceptor {

    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
    // 从头部取X-5E-USER字段值,解析成User对象
    val user = request.getHeader("X-5E-USER")?.let { objectMapper.readValue(it, User::class.java) }

    // 判断方法是否被@Authenticate注解,如果被注解了且user为空,则报401错误
    val authorize = (handler as HandlerMethod).beanType.getDeclaredAnnotation(Authenticate::class.java)
    ?: handler.getMethodAnnotation(Authenticate::class.java)
    if (authorize != null && user == null) {
    throw ResponseException(ResErrCode.NEED_AUTHORIZE)
    }
    return true
    }

    }
  • UserUpdateReq需要被验证

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @ApiModel("更新用户信息")
    class UserUpdateReq {

    @ApiModelProperty("用户名")
    @NotBlank
    var name: String? = null

    @ApiModelProperty("头像")
    @NotBlank
    var avatar: String? = null

    @ApiModelProperty("用户描述")
    @NotBlank
    var description: String? = null
    }

测试类的构建

测试类的构建,主要考虑以下几个因素

  • 只加载Web配置和待测Controller,其它Bean都不要,我们基于@WebMvcTest构建自己的注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    @Inherited
    @Documented
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @WebMvcTest
    // WebMvcConfig是本业务中自定义的Web配置
    @Import(WebMvcConfig.class)
    public @interface ControllerTest {

    @AliasFor(annotation = WebMvcTest.class)
    String[] properties() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class<?>[] value() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class<?>[] controllers() default {};

    @AliasFor(annotation = WebMvcTest.class)
    boolean useDefaultFilters() default true;

    @AliasFor(annotation = WebMvcTest.class)
    Filter[] includeFilters() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Filter[] excludeFilters() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class<?>[] excludeAutoConfiguration() default {};

    }
  • 一个Controller一个测试类,但每个接口都有好几个case,因此需要分组。用内部类+@Nested实现,不过这样的话,一个内部类对应一个接口,可以定义一个接口,管理公共属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    interface IRequestMapping {

    /**
    * 指定本次测试的路径
    */
    val path: String

    /**
    * 针对该路径的mock。这样,子类只需要调用mockRequest即可请求,而不需要每个case重新构建填写path、method等重复参数
    */
    fun mockRequest(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl

    fun mockRequestWithToken(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl {
    return mockRequest {
    header(mockAuthHeader.first, mockAuthHeader.second)
    block(this)
    }
    }

    fun mockRequestWithEmptyContent(block: MockHttpServletRequestDsl.() -> Unit = {}): ResultActionsDsl {
    return mockRequest {
    content = "{}"
    block(this)
    }
    }

    }
  • 实际上,所有的接口都有两个共同的需求,我们将其写成父类

    • 都需要测试鉴权

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      interface AuthenticateTest : IRequestMapping {
      @Test
      fun `401 when missing token`() {
      mockRequestWithEmptyContent().andExpect {
      status {
      isUnauthorized()
      }
      }
      }
      }
    • 都需要验证响应结构

      1
      2
      3
      4
      5
      6
      7
      8
      9
      interface ResponseFormatTest : IRequestMapping {
      @Test
      fun `response format validate`() {
      val mockResult = mockRequestWithEmptyContent().andReturn()
      assertThat(mockResult.response.contentType).isEqualTo(MediaType.APPLICATION_JSON.toString())
      val resNode = publicObjectMapper.readTree(mockResult.response.contentAsString)
      assertThat(publicObjectMapper.convertValue(resNode, R::class.java)).isInstanceOf(R::class)
      }
      }

这样,就构建得到如下测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76

@DisplayName("用户参数验证")
@ControllerTest(UserController::class)
class UserControllerTest {

@Autowired
private lateinit var mockMvc: MockMvc

@MockkBean
private lateinit var subscriptionService: SubscriptionService

@MockkBean(relaxed = true)
private lateinit var userService: UserService

// 同时需要验证鉴权和响应格式
@Nested
@DisplayName("更新用户信息")
inner class UpdateUserInfo : AuthenticateTest, ResponseFormatTest {

// 指定接口路径
override val path: String = "/users/1"

// 指定接口的method和contentType等公共属性
override fun mockRequest(block: MockHttpServletRequestDsl.() -> Unit): ResultActionsDsl {
return mockMvc.patch(path) {
contentType = MediaType.APPLICATION_JSON
block(this)
}
}

// 异常case的测试
@ParameterizedTest(name = "{displayName} {argumentsWithNames}")
@CsvSource(
value = [
"'', '', null",
"' ', null, 非空"
],
nullValues = ["null"]
)
fun `400 when illegal input`(name: String?, avatar: String?, description: String?) {
mockRequestWithToken {
val mockBody = UserUpdateReq().apply {
this.name = name
this.avatar = avatar
this.description = description
}
content = mockBody.toJsonString()
}.andExpect {
status { isBadRequest() }
}
}

// 正常case的测试
@ParameterizedTest(name = "{displayName} {argumentsWithNames}")
@CsvSource(
value = [
"null, null, null",
"null, null, 非空"
],
nullValues = ["null"]
)
fun `200 when legal input`(name: String?, avatar: String?, description: String?) {
mockRequestWithToken {
val mockBody = UserUpdateReq().apply {
this.name = name
this.avatar = avatar
this.description = description
}
content = mockBody.toJsonString()
}.andExpect {
status { isOk() }
}
}

}
}

关于Json格式的测试

当前项目一个比较特殊的需求:资源保存接口,资源的结构如下,每次保存时需要验证资源的结构是否符合预期。

1
2
3
4
5
6
7
8
9
{
"resourceId": "1233",
"resourceType": "record",
"data": {
"...": "...",
"...": "...",
"...": "...",
}
}

其中的data结构不固定,多达五六种。服务端将data的验证逻辑写成了json schema,以自定义validator的形式加入Spring中。现在要对这些验证逻辑进行单元测试。

这其中的测试难点及解决方法如下

  • 测试数据量大

    将测试数据写在文件中,通过文件读取各条测试数据,再以参数化形式传入测试case。需要用到JUnit的@ParameterizedTest + @MethodSource达成

  • 测试case多

    我们提取了最常见的case,对它们进行数据构建

    • 预期成功的case

      • 拥有最少合法元素
      • 拥有最多合法元素
      • 可空元素都为null

      以tag为例,可以看到除了数据之外我们还添加了针对该数据的说明:_comment_字段,这在测试结果中会打印出来

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      [
      {
      "_comment_": "拥有最少合法元素",
      "resourceId": "12345678901234567890123456789012",
      "resourceType": "tag",
      "usn": 1,
      "data": {
      "id": "12345678901234567890123456789012",
      "title": "5qCH562+5qCH6aKY",
      "created_time": 1,
      "updated_time": 1,
      "version": 3
      }
      },
      {
      "_comment_": "拥有最多合法元素",
      "resourceId": "12345678901234567890123456789012",
      "resourceType": "tag",
      "usn": 1,
      "data": {
      "id": "12345678901234567890123456789012",
      "title": "5qCH562+5qCH6aKY",
      "description": "5o+P6L+w",
      "created_time": 1,
      "updated_time": 1,
      "deleted_time": 1,
      "version": 3,
      "usn": 1
      }
      },
      {
      "_comment_": "可空元素都为null",
      "resourceId": "12345678901234567890123456789012",
      "resourceType": "tag",
      "usn": null,
      "data": {
      "id": "12345678901234567890123456789012",
      "title": "5qCH562+5qCH6aKY",
      "description": null,
      "created_time": 1,
      "updated_time": 1,
      "deleted_time": null,
      "version": 3,
      "usn": null
      }
      }
      ]
    • 预期失败的case

      • 必须字段未出现
      • 不可空元素设置为null
      • 数据类型问题:故意将每个元素的数据类型写错
      • 数据格式问题:故意将每个元素的数据格式写错
      • 出现未定义的字段

      同样以tag为例,不同的是这次还多了_errorKeywords_字段,指出了该case的错误信息必须包含的关键字

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      [
      {
      "_comment_": "必须字段未出现",
      "_errorKeywords_": [
      "id",
      "title",
      "created_time",
      "updated_time",
      "version"
      ],
      "resourceId": "12345678901234567890123456789012",
      "resourceType": "tag",
      "usn": 1,
      "data": {}
      },
      {
      "_comment_": "字段可空问题: id, title, created_time, updated_time, version",
      "_errorKeywords_": [
      "id",
      "title",
      "created_time",
      "updated_time",
      "version"
      ],
      "resourceId": "12345678901234567890123456789012",
      "resourceType": "tag",
      "usn": 1,
      "data": {
      "id": null,
      "title": null,
      "description": "5o+P6L+w",
      "created_time": null,
      "updated_time": null,
      "deleted_time": 1,
      "version": null,
      "usn": 1
      }
      },
      {
      "_comment_": "数据类型问题: id、title、description为string;其它为数字",
      "_errorKeywords_": [
      "id",
      "title",
      "description",
      "created_time",
      "updated_time",
      "deleted_time",
      "version",
      "usn"
      ],
      "resourceId": "12345678901234567890123456789012",
      "resourceType": "tag",
      "usn": 1,
      "data": {
      "id": 1,
      "title": 1,
      "description": 1,
      "created_time": "1",
      "updated_time": "1",
      "deleted_time": "1",
      "version": "3",
      "usn": "1"
      }
      },
      {
      "_comment_": "数据格式问题: id为32位;title、description为base64;version固定为3",
      "_errorKeywords_": [
      "id",
      "title",
      "description",
      "version"
      ],
      "resourceId": "12345678901234567890123456789012",
      "resourceType": "tag",
      "usn": 1,
      "data": {
      "id": "123456789012345678901234567890",
      "title": "明文标题",
      "description": "明文描述",
      "created_time": 1,
      "updated_time": 1,
      "deleted_time": 1,
      "version": 2,
      "usn": 1
      }
      },
      {
      "_comment_": "出现未定义字段: undefinedField",
      "_errorKeywords_": [
      "undefinedField"
      ],
      "resourceId": "12345678901234567890123456789012",
      "resourceType": "tag",
      "usn": 1,
      "data": {
      "id": "12345678901234567890123456789012",
      "title": "5qCH562+5qCH6aKY",
      "description": "5o+P6L+w",
      "created_time": 1,
      "updated_time": 1,
      "deleted_time": 1,
      "version": 3,
      "usn": 1,
      "undefinedField": ""
      }
      }
      ]

    目前为止所有数据有这么多:

    image-20220305174323051

至于测试方法,我们这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@ParameterizedTest(name = "{displayName} {argumentsWithNames}")
@MethodSource("com.project5e.app.mylog.controller.mock.ResourceProvider#provideLegalResource")
fun `200 when legal resource`(type: String, comment: String, content: String) {
every { resourceService.saveV2(any()) } returns listOf()
mockRequestWithToken {
this.content = content
}.andExpect {
status { isOk() }
}
}

@ParameterizedTest(name = "{displayName} {argumentsWithNames}")
@MethodSource("com.project5e.app.mylog.controller.mock.ResourceProvider#provideIllegalResource")
fun `400 when illegal resource`(type: String, comment: String, errorKeyWords: List<String>, content: String) {
val mvcResult = mockRequestWithToken {
this.content = content
}.andExpect {
status { isBadRequest() }
}.andReturn()

errorKeyWords.forEach { errorKeyWord ->
assertThat(mvcResult.response.contentAsString).contains(errorKeyWord)
}
}

测试数据读取方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
object ResourceProvider {

private const val RESOURCE_FILE_SUFFIX = "json"
private const val LEGAL_RESOURCE_PATTERN = "classpath:mock-resource/legal/*.$RESOURCE_FILE_SUFFIX"
private const val ILLEGAL_RESOURCE_PATTERN = "classpath:mock-resource/illegal/*.$RESOURCE_FILE_SUFFIX"

private const val COMMENT_KEY = "_comment_"
private const val ERROR_KEYWORDS_KEY = "_errorKeywords_"

@JvmStatic
fun provideLegalResource(): List<Arguments> {
return PathMatchingResourcePatternResolver().getResources(LEGAL_RESOURCE_PATTERN).map { resource ->
resource.file.name to publicObjectMapper.readTree(resource.file)
}.map { (fileName, mockResources) ->
mockResources.map { mockResource ->
val resourceType = fileName.removeSuffix(RESOURCE_FILE_SUFFIX)
val comment = (mockResource as ObjectNode).remove(COMMENT_KEY).asText()
val content = """{"resources": [${mockResource.toJsonString()}]}"""
Arguments.of(resourceType, comment, content)
}
}.flatten()
}

@JvmStatic
fun provideIllegalResource(): List<Arguments> {
return PathMatchingResourcePatternResolver().getResources(ILLEGAL_RESOURCE_PATTERN).map { resource ->
resource.file.name to publicObjectMapper.readTree(resource.file)
}.map { (fileName, mockResources) ->
mockResources.map { it as ObjectNode }.map { mockResource ->
val resourceType = fileName.removeSuffix(RESOURCE_FILE_SUFFIX)
val comment = mockResource.remove(COMMENT_KEY).asText()
val errorKeywords = mockResource.remove(ERROR_KEYWORDS_KEY).map { it.asText() }
val content = """{"resources": [${mockResource.toJsonString()}]}"""
Arguments.of(resourceType, comment, errorKeywords, content)
}
}.flatten()
}
}

如果运行测试,能够得到如下输出

image-20220305174813410

可以改进的地方

  • controller层测试还可以再抽象:不需要写代码,通过excel或db录入访问路径、预期输入、预期输出,直接生成测试代码生成,更为方便
  • controller层测试或许可以和集成测试合并。但也不尽然,集成测试不一定要在controller这一次层做,以service为入口或许更加方便

Service测试

测什么

  • 测试业务逻辑

一个需求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
class UserService(@Autowired private val userDao: UserDao) {

@Value("\${user.appid}")
lateinit var appId: String

@Value("\${user.fresh.avatar}")
lateinit var freshUserAvatar: String

@GrpcClient("user-service")
lateinit var userStub: UserServiceGrpc.UserServiceBlockingStub

fun updateUserInfo(id: Int, initiator: Int, request: UserUpdateReq): User {
if (id != initiator) {
throw ResponseException(ResErrCode.ILLEGAL_REQUEST, "不可修改他人信息")
}
if (!request.isAllFieldNull()) {
userDao.updateUserInfo(id, request)
}
return getUserInfo(id)
}
}

构建测试类

在Controller测试时,我们采用了@Nested+内部类的方式对接口进行了分组。但是在Service中,往往有很多个方法,每个方法的case也会非常多,如果还采用和Controller一样的方式,测试类会爆炸。因此我们采用类继承的方式进行分组。

父类如下:定义了mock对象和测试对象,以及测试对象中的共享mock属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@ExtendWith(MockKExtension::class)
open class UserServiceTest {

@RelaxedMockK
protected lateinit var userDao: UserDao

@InjectMockKs
protected lateinit var userService: UserService

@BeforeEach
@Order(Int.MIN_VALUE)
fun setUp() {
userService.appId = MOCK_APP_ID
userService.freshUserAvatar = MOCK_FRESH_USER_AVATAR
userService.userStub = mockk(relaxed = true)
}

}

针对需求中的方法的测试类如下,只需要写测试方法即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@DisplayName("UserService测试 > 更新用户信息")
class UpdateUserInfoTest : UserServiceTest() {

@Test
fun `reject when update others info`() {
val mockUserId = MOCK_USER_ID
val mockInitiatorId = MOCK_USER_ID + 1
val mockRequest = UserUpdateReq()

assertThat { userService.updateUserInfo(mockUserId, mockInitiatorId, mockRequest) }.isFailure()
.isInstanceOf(ResponseException::class)
.matchesPredicate { it.errorCode == ResErrCode.ILLEGAL_REQUEST }
}

@Test
fun `success when empty request`() {
val spyUserService = spyk(userService)
every { spyUserService.getUserInfo(any()) } returns User()

spyUserService.updateUserInfo(MOCK_USER_ID, MOCK_USER_ID, UserUpdateReq())

verify { userDao wasNot called }
}

@Test
fun `success when non-empty request`() {
val mockUserId = MOCK_USER_ID
val mockInitiatorId = MOCK_USER_ID
val mockRequest = UserUpdateReq().apply {
this.name = "假名,只让它非空"
}
val spyUserService = spyk(userService)
val mockUpdateResult = User()

every { spyUserService.getUserInfo(any()) } returns mockUpdateResult

val updateResult = spyUserService.updateUserInfo(mockUserId, mockInitiatorId, mockRequest)

val idSlot = slot<Int>()
val requestSlot = slot<UserUpdateReq>()
verify { userDao.updateUserInfo(capture(idSlot), capture(requestSlot)) }
assertThat(idSlot.captured).isEqualTo(mockUserId)
assertThat(requestSlot.captured).isEqualTo(mockRequest)
assertThat(updateResult).isEqualTo(mockUpdateResult)
}

}

如何脱离Spring

当Service依赖Bean的注入使用构造器注入时,对Service层的单元测试就能脱离Spring:因为MockK在构建测试对象时,是通过构造器注入的。否则就得一个个手动mock

让代码更易于测试

最易于测试的代码当然是纯函数式,一个确定的输入就能对应一个确定的输出。但理想与现实是有差别的,日常业务中过分追求函数式可能会使得代码晦涩难懂。尽量函数式+mock才是正解。但是副作用和函数内部的级联调用过多,又会造成mock灾难。所以还是一个权衡的过程。

按照个人经验总结一下就是

  • 函数式+适当mock
  • 功能拆解:复杂方法拆分成若干小的方法,分别测试,这样弹性更高

一个例子:如下是重构之前的一个方法(获取用户邀请信息),如此之大,很显然是无法测试的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
fun getInvitationInfo(): InvitationInfoResponseDto {
val currentUser = RequestContext.currentUser
// 从库中获取邀请码,没有就生成一个插入库中
var userModel = userDao.getById(currentUser)
if (userModel.invitationCode.isNullOrEmpty()) {
userModel.invitationCode = userDao.generateInvitationCode().toUpperCase()
userModel.invitedCount = 0
// 尝试更新,如果提示冲突,说明发生了并发问题,以库中已有为准。不过这能发生的前提是数据表针对invitation_code做了unique限制
runCatching { userModel.updateById() }.exceptionOrNull()
?.takeIf { it.message?.contains("duplicate") == true }
?.run { userModel = userDao.getById(currentUser) }
}

// 查出一串记录,作为后面的计算依据
val invitedHistory = invitationHistoryDao.getByInvitee(currentUser)
val redeemHistory = invitationRedeemHistoryDao.listByRedeemer(currentUser)
val subscriptionModel = subscriptionService.ktQuery().eq(SubscriptionModel::userId, currentUser).one()

// 奖品构建:基本信息固定,只需填充兑换状态、是否可用(置灰状态)
val awards = awardModels.map { InvitationInfoResponseDto.Award().fillWith(it) }.onEach { award ->
award.redeemed = redeemHistory.any { it.awardId == award.id }
// 常规case:邀请数够且未被兑换,则高亮。否则置灰
award.enable = (userModel.invitedCount!! >= award.triggerCount!!) && !award.redeemed!!
}
val standardAward = awards.single { it.membershipLevel == MembershipLevel.STANDARD }
val premiumAward = awards.single { it.membershipLevel == MembershipLevel.PREMIUM }
// 特殊case1:只要高级有效(要么购买要么兑换),普通奖励就置灰
if (subscriptionModel?.membershipLevel == MembershipLevel.PREMIUM) {
standardAward.enable = false
}

// 构建完整的邀请信息
return InvitationInfoResponseDto().apply {
this.invitationCode = userModel.invitationCode
this.invitedCount = userModel.invitedCount
this.hasBeenInvited = invitedHistory != null
this.awards = awards
}
}

如下是重构之后的代码,按功能拆分,不但更易测,可读性也增强了不少。之前的代码不一点点看你一定不知道它在干啥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
fun getInvitationInfo(): InvitationInfoResponseDto {
val currentUser = RequestContext.currentUser
val (invitationCode, invitedCount) = getInvitationCodeAndInvitedCount(currentUser)
val hasBeenInvited = hasBeenInvited(invitationHistoryDao.getByInvitee(currentUser))
val awards = awardModels.map { InvitationInfoResponseDto.Award().fillWith(it) }
fillAwardRedeemed(awards, invitationRedeemHistoryDao.listByRedeemer(currentUser))
fillAwardEnable(awards, currentUser, invitedCount, subscriptionDao.getByUserId(currentUser))

return InvitationInfoResponseDto().apply {
this.invitationCode = invitationCode
this.invitedCount = invitedCount
this.hasBeenInvited = hasBeenInvited
this.awards = awards
}
}

fun getInvitationCodeAndInvitedCount(userId: Int): Pair<String, Int> {
var userModel = userDao.getById(userId)!!
if (userModel.invitationCode.isNullOrEmpty()) {
userModel.invitationCode = generateCode()
userModel.invitedCount = 0
// 尝试更新,如果提示冲突,说明发生了并发问题,以库中已有为准。前提:invitation_code做了unique限制
try {
userDao.updateById(userModel)
} catch (_: DuplicateKeyException) {
userModel = userDao.getById(userId)!!
}
}
return userModel.invitationCode!! to userModel.invitedCount!!
}

fun generateCode(): String {
val tryCount = 3
(0..tryCount).forEach { _ ->
val candidate = UUID.randomUUID().toString().takeLast(6).toUpperCase()
if (userDao.getByInvitationCode(candidate) == null) {
return candidate
}
}
log.error("邀请码生成失败,请查找问题")
throw ResponseException(ResErrCode.COMM_ERROR)
}

fun hasBeenInvited(invitationHistoryModel: InvitationHistoryModel?): Boolean {
return invitationHistoryModel != null
}

fun fillAwardRedeemed(
awards: List<InvitationInfoResponseDto.Award>, redeemHistory: List<InvitationRedeemHistoryModel>
) = awards.forEach { award ->
award.redeemed = redeemHistory.any { it.awardId == award.id }
}

fun fillAwardEnable(
awards: List<InvitationInfoResponseDto.Award>, userId: Int, invitedCount: Int, subscription: SubscriptionModel?
) {
awards.forEach { award ->
award.enable = (invitedCount >= award.triggerCount!!) && !award.redeemed!!
}
val standardAward = awards.single { it.membershipLevel == MembershipLevel.STANDARD }
// 特殊case1:只要高级有效(要么购买要么兑换),普通奖励就置灰
if (subscription?.membershipLevel == MembershipLevel.PREMIUM) {
standardAward.enable = false
}
}

一个经验:在重构一个大方法时,预想拆分成多个子方法,每个方法写成完全无副作用、无级联方法调用,这样做的结果是子方法包含很多高阶方法,可读性相较于前,差了不少。

不要过分纠结BDD风格

BDD风格即所谓的 given - when - then 结构(mock数据 - 执行被测方法 - 断言判断),AssertJ提供BDD风格的断言。但given阶段由Mock库决定,then阶段由断言库决定。这两个库通常还不是一个,所以API很可能组合不出预想的效果。还不如老老实实用every{}进行mock,assertThat()进行断言,约定俗成,言简意赅,大家都懂。

Repository测试

对该层的测试难点及解决方法如下

  • 数据库环境难以搭建

    对于MySQL,还有嵌入式数据库H2可供选择。但对于PostgreSQL,有一个更好的选择:embedded-postres,测试期间启动数据库,支持PG11版本以上,支持run in docker。这是真实的环境,应该说是绝佳选择了。

  • MyBatis Plus写的如何测试

    MyBatsi Plus有提供测试支持,仅加载数据库相关内容。

如下自定义的注解,包含了所有测试必须的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Inherited
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
// mybatis plus的测试支持
@MybatisPlusTest
// 关闭测试完成后的事务回滚(@MybatisPlusTest默认开启,但我们是单元测试单独的环境,测试完成后会被销毁,因此不需要)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
// 导入自定义的针对MyBatis的配置,主要是Mapper扫描位置
@Import(MyBatisConfig.class)
// 扫描dao、model等内容
@ComponentScan("com.project5e.app.mylog.repo")
// PG的嵌入式数据库开启
@AutoConfigureEmbeddedDatabase(type = AutoConfigureEmbeddedDatabase.DatabaseType.POSTGRES, refresh = AutoConfigureEmbeddedDatabase.RefreshMode.AFTER_EACH_TEST_METHOD)
// 嵌入式数据库的初始化走Flyway
@FlywayTest
public @interface RepositoryTest {
}

测什么

  • 测试表完整性
  • 测试SQL正确性

关于Flyway

flyway的使用,自行查阅手册,在这里的作用是:embedded-postres在启动本地数据库后,将使用flyway对数据库初始化。所以,src/db/migration下的sql必须组成一个完整的数据表环境。

image-20220305185526827

表完整性测试

包括如下内容

  • 不可空字段检查
  • 可空或具有默认值的字段检查
  • 唯一索引检查

由于每个表都需要进行完整性测试,因此我们写成一个抽象类。子类需要提供预期不允许为空的字段列表、具有默认值字段和默认值映射、唯一索引和用于测试它们的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
abstract class RepositoryIntegrityTest<M : BaseMapper<T>, T> {

/**
* 非空且无默认值的字段
*/
abstract val nonNullableWithoutDefaultValueFields: List<KMutableProperty<*>>

/**
* 可空或有默认值的字段
*/
abstract val nullableOrDefaultFields: Map<KMutableProperty<*>, Any?>

/**
* 唯一索引列表和测试它们的数据(当存在多个唯一索引时,测试数据不好造,还是交给实现类比较好)
*/
abstract val uniqueIndexesWithTestData: Map<List<KMutableProperty<*>>, List<T>>

abstract fun getDao(): ServiceImpl<M, T>

/**
* 提供一个所有字段都不为空的基础model,用于测试
*/
abstract fun createModelWithAllFieldNotNull(): T

@Test
fun `validate when field absent (without default value)`() {
assertAll {
nonNullableWithoutDefaultValueFields.forEach { prop ->
// 待测字段填充null
val mockModel = createModelWithAllFieldNotNull().apply { prop.setter.call(this, null) }
// 填充null的字段都有应该报错
val result = Result.runCatching { getDao().save(mockModel) }
assertThat(result, prop.name).isFailure().matchesPredicate {
val nameInDb = StringUtils.camelToUnderline(prop.name)
it.message!!.contains("""null value in column "$nameInDb" violates not-null constraint""")
}
}
}
}

@Test
fun `validate when field absent (with default value)`() {
// 清空数据库
clearTable()
// 字段填充null
val mockModel = createModelWithAllFieldNotNull().apply {
nullableOrDefaultFields.forEach { (prop, _) ->
prop.setter.call(this, null)
}
}
// 保存然后查出
getDao().save(mockModel)
val insertedModel = getDao().list().first()
// 所有设置为null的字段查询结果都应该与预期的一致
assertAll {
nullableOrDefaultFields.forEach { (prop, expectedValue) ->
if (expectedValue != null && expectedValue::class == Any::class) {
assertThat(prop.getter.call(insertedModel), prop.name).isNotNull()
} else {
assertThat(prop.getter.call(insertedModel), prop.name).isEqualTo(expectedValue)
}
}
}
}

@Test
fun `validate unique constraint`() {
clearTable()
// 构建两个拥有一样字段值的对象
assertAll {
uniqueIndexesWithTestData.forEach { (index, testModels) ->
val result = Result.runCatching { getDao().saveBatch(testModels) }
assertThat(result, index.toString()).isFailure().isInstanceOf(DuplicateKeyException::class)
.matchesPredicate {
it.message!!.contains("already exists")
}
}
}
}

fun clearTable() {
getDao().ktUpdate().remove()
}

}

以表senior_user为例,实现它如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@RepositoryTest
class SeniorTest : RepositoryIntegrityTest<SeniorUserMapper, SeniorUserModel>() {

@Autowired
private lateinit var seniorUserDao: SeniorUserDao

override val nonNullableWithoutDefaultValueFields: List<KMutableProperty<*>> = listOf(
SeniorUserModel::id
)

override val nullableOrDefaultFields: Map<KMutableProperty<*>, Any?> = mapOf(
SeniorUserModel::createdTime to Any(),
SeniorUserModel::updatedTime to Any(),
SeniorUserModel::redeemed to false
)

override val uniqueIndexesWithTestData: Map<List<KMutableProperty<*>>, List<SeniorUserModel>> = mapOf(
listOf(SeniorUserModel::id) to listOf(createModelWithAllFieldNotNull(), createModelWithAllFieldNotNull())
)

override fun getDao(): ServiceImpl<SeniorUserMapper, SeniorUserModel> {
return seniorUserDao
}

final override fun createModelWithAllFieldNotNull(): SeniorUserModel {
return SeniorUserModel().apply {
this.id = MOCK_USER_ID.toLong()
this.createdTime = OffsetDateTime.now()
this.updatedTime = OffsetDateTime.now()
this.redeemed = false
}
}
}

SQL正确性测试

通常的做法是:构建mock数据,插入数据库 -> 调用被测SQL -> 判断结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
fun `test update user info partially`() {
// 清空表
clearTable()
// 构建测试数据
val oldModel = createModelWithAllFieldNotNull()
userDao.save(oldModel)

// 调用被测SQL方法
val mockUserId = MOCK_USER_ID
val mockUserName = MOCK_USER_NAME + "gt"
val mockRequest = UserUpdateReq().apply {
this.name = mockUserName
}
userDao.updateUserInfo(mockUserId, mockRequest)
// 取出数据
val newModel = userDao.getById(mockUserId)
// 判断结果
assertThat(newModel.id).isEqualTo(mockUserId)
assertThat(newModel.name).isEqualTo(mockRequest.name)
assertThat(newModel.avatar).isEqualTo(oldModel.avatar)
assertThat(newModel.description).isEqualTo(oldModel.description)
}

被测SQL如下(当然它也不一定是个SQL,也可能是基于底层库构建的对数据库的操作,比如这里)

1
2
3
4
5
6
7
8
fun updateUserInfo(id: Int, request: UserUpdateReq): Boolean {
return UserModel(
id = id,
name = request.name,
avatar = request.avatar,
description = request.description,
).run { updateById() }
}

MyBatis Plus的SQL写在哪里

MyBatis Plus提供了类public class ServiceImpl<M extends BaseMapper<T>, T> implements IService<T>,提供三个能力

  • 快捷方法,如save、list、getById等
  • 提供写SQL的DSL能力
  • 提供baseMapper,对mapper直接调用

如此方便,以至于很多业务将Service类继承了它,省去了定义DAO的麻烦。个人认为,这样做在能力1和能力3的使用上都没有问题,但利用DSL构建SQL的能力对Service层造成了入侵,至少它不是容易测试的代码:

  • 如果构建的SQL DSL和业务代码混在一起,则根本无法测试
  • 如果将其提成一个单独的方法,那为什么不专门定义一个DAO来负责呢?能力1和能力3调用的方便性没有变差,能力2也得到了的有效管控

所以我的看法是:应该将ServiceImpl锁定在DAO里,不要侵入到业务代码。

测试锁

如下SQL如何测试,测试重点在于FOR UPDATE

1
2
3
4
5
6
fun lockUser(userId: Int) {
ktQuery()
.eq(UserModel::id, userId)
.last("FOR UPDATE")
.list()
}

分析难点及解决方案

  • 需要在两个并发的事物中分别调用才能测试

    可以利用线程+手动管理事务+延迟

  • 如何验证是否成功

    使用SELECT XXXX FOR UPDATE NOWAIT语句,检测到锁冲突时马上报错

于是代码可以如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 事务管理器,直接注入即可
@Autowired
private lateinit var tm: DataSourceTransactionManager

@Test
fun `test lock user`() {
// 注意latch的使用
val latch = CountDownLatch(2)

val transaction1 = Runnable {
// 注意编程式事务的使用方式
val transactionStatus = tm.getTransaction(TransactionDefinition.withDefaults())
userDao.lockUser(MOCK_USER_ID)
TimeUnit.SECONDS.sleep(2)
tm.commit(transactionStatus)
latch.countDown()
}
val transaction2 = Runnable {
TimeUnit.SECONDS.sleep(1)
val transactionStatus = tm.getTransaction(TransactionDefinition.withDefaults())
try {
assertThat {
userDao.ktQuery().eq(UserModel::id, MOCK_USER_ID).last("FOR UPDATE NOWAIT").list()
}.isFailure().isInstanceOf(CannotAcquireLockException::class)
} finally {
tm.commit(transactionStatus)
latch.countDown()
}
}

Thread(transaction1).start()
Thread(transaction2).start()
latch.await()
}

问题记录

  • 使用@WebMvcTest单独测试Controller时,报错:无法找到xxxMapper

    原因:默认情况下,@WebMvcTest会将SpringBootApplication注解的那个启动类当做配置主类,而之前将@MapperScan写在了上面。

    解决:将@MapperScan转移到单独的配置文件中。同时也提醒:配置不要乱放

  • logback-test.xml中指定的日志等级不生效

    原因:如果在src/main/resources/application.properties中指定了logging.root.level,在配置文件中的等级就不会生效

    解决:移除logging.root.level

    要彻底解决,可以参考这篇文章

  • 配置问题:Controller测试过程中发现一些针对Web的配置未生效

    原因:配置类未被@WebMvcTest加载

    解决:手动导入自定义的Web配置

  • Kotlin兼容性问题:@NotBlank对写在构造方法中的Kotlin属性不生效

    原因:验证库和Kotlin的兼容性问题

    解决:使用@field:NotBlank或者将属性从构造方法移到类体

  • MockK无法调用、mock私有方法

    解决:MockK提供这样的功能,但使用起来不是很方便。所以这个问题尚未有较好的解决方式

总结

我花了两周去了解单元测试常用技术,细读JUnit用户手册,浏览了Spring Boot Test手册(但是忘记看Spring Test手册,是说用的时候觉得哪里不对,😓),去了解了如何正确地写单元测试。又花了两周对每记后端项目进行TDD重构,先后尝试了Mockito、AssertJ和MockK、AssertK,发现用Kotlin时,MockK比Mockito香得不是一点点(Mockito写得像Java,mock静态方法时还得打补丁,对Kotlin DSL的补丁看起来并不特别好;MockK就不一样了,功能全面,使用优雅)。

在补单元测试时,还修复了一些之前没注意也没测出来的bug,所谓矫枉过正,现在的我觉得没有单元测试的代码,是非常不可靠的。

本周开发新功能时,同步添加单元测试。两点感受颇深

  • 写起来确实放心不少。写测试的过程中,已经细细品过很多边缘case了。也省去了本地启动服务一遍遍手动测试的麻烦。
  • 是真的挺花费时间。扪心自问,如果进度要求再急一点,这单元测试可能写不下去,还得后面补。所以,要给自己预留充足的时间。

回看刚写的这些单测,还是有很多问题的

  • case数量多,当前有将近300个case(算上条件测试)。根据80%的bug出在20%代码中的定律,可以依靠经验识别容易出问题的地方,集中测试,其它地方,相对可以放松警惕。这样能够节省写单测时间
  • 测试速度慢,CI机上跑完所有case需要2:30左右。速度有待优化
  • 测试代码质量有待加强

而就整个测试工作来说,要做的也还有很多

  • 集成测试:目前没有集成测试,所以各层的协作还是靠手动测试
  • CI+接口测试自动化:当前CI负责从构建、单元测试、发布应用的过程;还可以更加自动化:发布使用金丝雀,检测应用发布完成后对所有接口进行冒烟测试,测试通过再替换实际应用,否则就只发布金丝雀,从而最大程度降低发布风险。
  • 推广单元测试:单测值得推广吗?肯定是值得的。但说实话,让我去写table应用的单元测试,我也会心生抗拒,毕竟case太多了。如果克服这些问题,让大家认真写测试,是个课题。

留言

2022-03-06

⬆︎TOP